All files / src/app/api/collections/[id]/items/[itemId]/photos/[photoId] route.ts

96% Statements 48/50
100% Branches 28/28
75% Functions 3/4
95.83% Lines 46/48

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123                                2x               13x 13x 1x     12x 12x 12x   12x 12x       12x 12x   8x 8x 1x   7x 7x 1x       6x 6x   1x   5x 2x     3x 3x                   13x 13x 1x       12x   12x 12x 12x   12x 12x       12x 12x     8x 8x 1x     7x 7x 1x             6x       6x 5x   6x   6x              
import { NextRequest, NextResponse } from "next/server";
import { getSession } from "@/lib/auth";
import { getDb } from "@/lib/db";
import { PhotoController } from "@/server/api/photos";
import { ItemController } from "@/server/api/items";
import { requireApiCapability } from "@/server/dal/collection-access";
import { del } from "@vercel/blob";
import { ALLOWED_ROTATIONS } from "@/lib/photos";
import { deleteVariants, variantPrefix } from "@/lib/photo-variants";
import { errorResponse } from "@/server/api/http-errors";
import { serveItemPhotoFile } from "@/server/api/item-photo-file";
 
export async function GET(
  req: NextRequest,
  { params }: { params: Promise<{ id: string; itemId: string; photoId: string }> }
) {
  return serveItemPhotoFile(req, params);
}
 
// Rotate a photo (persisted, non-destructive). photos:write = curator (full-curator model).
export async function PATCH(
  req: NextRequest,
  { params }: { params: Promise<{ id: string; itemId: string; photoId: string }> }
) {
  const session = await getSession();
  if (!session?.user) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }
 
  const db = getDb();
  const photoCtrl = PhotoController.fromDb(db);
  const itemCtrl = ItemController.fromDb(db);
 
  try {
    const { id, itemId, photoId } = await params;
    // Authz before existence (purses-ct0): the collection is in the URL, so a
    // non-curator never learns whether the item/photo exists.
    // photos:write = curator — any curator may rotate photos (full-curator model).
    const access = await requireApiCapability(db, id, session.user.id, "photos:write");
    if (access instanceof NextResponse) return access;
 
    const item = await itemCtrl.getItem(itemId);
    if (!item || item.collectionId !== id) {
      return NextResponse.json({ error: "Not found" }, { status: 404 });
    }
    const photo = await photoCtrl.findById(photoId);
    if (!photo || photo.itemId !== itemId) {
      return NextResponse.json({ error: "Not found" }, { status: 404 });
    }
 
    let rotation: unknown;
    try {
      ({ rotation } = await req.json());
    } catch {
      return NextResponse.json({ error: "Invalid body" }, { status: 400 });
    }
    if (!(ALLOWED_ROTATIONS as readonly unknown[]).includes(rotation)) {
      return NextResponse.json({ error: "Invalid rotation" }, { status: 400 });
    }
 
    const updated = await photoCtrl.update(photoId, { rotation: rotation as number });
    return NextResponse.json({ rotation: updated.rotation, updatedAt: updated.updatedAt });
  } catch {
    return NextResponse.json({ error: "Rotate failed" }, { status: 500 });
  }
}
 
export async function DELETE(
  _req: NextRequest,
  { params }: { params: Promise<{ id: string; itemId: string; photoId: string }> }
) {
  const session = await getSession();
  if (!session?.user) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }
  // Capture before the tx closure — TS loses the `!session?.user` narrowing inside
  // an async callback, and this is the audit actor.
  const actorId = session.user.id;
 
  const db = getDb();
  const photoCtrl = PhotoController.fromDb(db);
  const itemCtrl = ItemController.fromDb(db);
 
  try {
    const { id, itemId, photoId } = await params;
    // Authz before existence (purses-ct0) — see PATCH above.
    // photos:write = curator — any curator may delete photos (full-curator model).
    // Curator-level by owner decision; attributed via audit (purses-opir/wfgj).
    const access = await requireApiCapability(db, id, actorId, "photos:write");
    if (access instanceof NextResponse) return access;
 
    // Verify item belongs to collection (belongs-to-parent).
    const item = await itemCtrl.getItem(itemId);
    if (!item || item.collectionId !== id) {
      return NextResponse.json({ error: "Not found" }, { status: 404 });
    }
 
    const photo = await photoCtrl.findById(photoId);
    if (!photo || photo.itemId !== itemId) {
      return NextResponse.json({ error: "Not found" }, { status: 404 });
    }
 
    // Row delete + audit in ONE transaction via PhotoController.deleteWithAudit:
    // a committed delete always has exactly one audit row — if the audit insert
    // throws, the delete rolls back (purses-wfgj). Blob del + variant sweep are
    // external side effects that can't roll back, so they run post-commit (best-effort).
    await photoCtrl.deleteWithAudit(photo, actorId);
 
    // Best-effort, post-commit: only http(s) keys are real blob URLs; a del
    // failure is inert (orphaned, sweepable) — the DB delete already committed.
    if (photo.blobKey.startsWith("http")) {
      await del(photo.blobKey);
    }
    await deleteVariants(variantPrefix(id, photoId)).catch(() => {});
 
    return NextResponse.json({ success: true });
  } catch (error) {
    // Every expected failure returns explicitly above, so anything reaching here is
    // unexpected — errorResponse logs it and returns generic copy (purses-hiez).
    return errorResponse(error);
  }
}